- Published on
- ·
토큰 만료 때문에 실패한 요청이 동시에 여러개일 때
문제 상황
웹 애플리케이션에서 여러 API 요청이 거의 동시에 발생하는 경우가 종종 있습니다. 이 때 요청들이 토큰 만료로 인해 실패했다고 가정해봅시다. 이 경우, 각 실패 요청마다 토큰을 갱신했을 때 서로 다른 Access Token
을 갱신하여 마지막 갱신 전 다른 요청들은 재시도에 실패하거나, 서버에 불필요한 부담을 줄 수 있습니다.
플래그와 재시도 대기 큐를 활용한 토큰 갱신
여러 요청 중 첫 번째 실패 요청에 대해서만 토큰 갱신을 시도하고 ,이후의 요청들은 토큰 갱신이 완료될 때까지 큐에 대기하도록합니다. 토큰 갱신 후, 대기중이던 요청들은 갱신된 토큰으로 순서대로 재시도됩니다.
구현
axios interceptor
를 활용하여 401로 실패한 모든 요청에 대해서 관리합니다.
토큰 갱신 중 플래그 관리
isRefreshing
플래그를 사용하여 토큰 갱신 중인지 여부를 관리합니다. 이 플래그가 true
일 경우, 새로운 요청들은 토큰 갱신이 완료될때까지 대기해야 합니다.
재요청 대기열 관리
토큰 갱신을 기다리는 동안 실패한 요청들은 failedQueue
큐에 저장됩니다. 비록 실제로 배열을 사용하고 요소를 하나씩 shift
하여 사용하지는 않지만, 요소들이 먼저 들어온 순서대로 처리되므로 이를 큐라고 부르겠습니다. 각 요소는 재시도를 위해 resolve
와 reject
함수를 포함합니다.
type PromiseResolveReject = {
resolve: (value?: string | PromiseLike<string> | undefined) => void;
reject: (reason?: any) => void;
};
let failedQueue: PromiseResolveReject[] = [];
대기열 처리 로직
processFailedQueue
함수는 토큰 갱신 후 대기 중인 요청들을 순차적으로 처리합니다. 갱신 성공 시 resolve
를, 실패 시 reject
를 호출하여 각 요청을 재시도하거나 최종 실패 처리합니다.
const processFailedQueue = (error: any, token: string | undefined = undefined): void => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
이미 토큰 갱신 중이면 대기열에 추가
플래그를 통해 이미 갱신중인 것을 확인하면, 토큰을 새로 갱신하고 바로 재요청을 시도하는 것이 아니라 재요청 대기열에 들어가서 토큰 갱신을 기다립니다. 이 Promise
는 processFailedQueue
가 실행될 때 resolve
되어 갱신된 토큰과 함게 다시 요청을 보낼 것입니다.
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
if (token) {
originalRequest.headers.authorization_access = `Bearer ${token}`;
}
return axiosInstance(originalRequest);
})
.catch((err) => Promise.reject(err));
}
첫 실패 시 토큰 갱신 후 대기열 처리
토큰이 갱신 중이 아닐 때는 직접 토큰을 갱신하고, 본인 요청을 재시도 하면서 그 사이에 들어온 요청이 있을 수 있으니 processFailedQueue
를 통해 이를 처리합니다.
return new Promise((resolve, reject) => {
tokenRefresh()
.then((token) => {
originalRequest.headers.authorization_access = `Bearer ${token}`;
processFailedQueue(null, token);
resolve(axiosInstance(originalRequest));
})
.catch((err) => {
processFailedQueue(err, undefined);
reject(err);
})
.finally(() => {
isRefreshing = false;
});
});
전체 코드
let isRefreshing = false;
type PromiseResolveReject = {
resolve: (value?: string | PromiseLike<string> | undefined) => void;
reject: (reason?: any) => void;
};
let failedQueue: PromiseResolveReject[] = [];
const processFailedQueue = (error: any, token: string | undefined = undefined): void => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const {
config,
response: { status },
} = error;
const originalRequest = config;
if (status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
if (token) {
originalRequest.headers.authorization_access = `Bearer ${token}`;
}
return axiosInstance(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
return new Promise((resolve, reject) => {
tokenRefresh()
.then((token) => {
originalRequest.headers.authorization_access = `Bearer ${token}`;
processFailedQueue(null, token);
resolve(axiosInstance(originalRequest));
})
.catch((err) => {
processFailedQueue(err, undefined);
reject(err);
})
.finally(() => {
isRefreshing = false;
});
});
}
return Promise.reject(error);
},
);